paint-brush
La propiedad y los préstamos de Rust hacen cumplir la seguridad de la memoriapor@senthilnayagan
2,138 lecturas
2,138 lecturas

La propiedad y los préstamos de Rust hacen cumplir la seguridad de la memoria

por Senthil Nayagan31m2022/07/15
Read on Terminal Reader
Read this story w/o Javascript

Demasiado Largo; Para Leer

La propiedad y el préstamo de Rust pueden ser confusos si no comprendemos lo que realmente está sucediendo. Esto es particularmente cierto cuando se aplica un estilo de programación previamente aprendido a un nuevo paradigma; llamamos a esto un cambio de paradigma. Si un programa no es realmente seguro para la memoria, hay pocas garantías sobre su funcionalidad.

Companies Mentioned

Mention Thumbnail
Mention Thumbnail
featured image - La propiedad y los préstamos de Rust hacen cumplir la seguridad de la memoria
Senthil Nayagan HackerNoon profile picture

La propiedad y los préstamos de Rust pueden ser confusos si no comprendemos lo que realmente está sucediendo. Esto es particularmente cierto cuando se aplica un estilo de programación previamente aprendido a un nuevo paradigma; llamamos a esto un cambio de paradigma. La propiedad es una idea novedosa, pero difícil de entender al principio, pero se vuelve más fácil cuanto más trabajamos en ella.


Antes de continuar con la propiedad y el préstamo de Rust, primero comprendamos qué son la "seguridad de la memoria" y la "fuga de memoria" y cómo los lenguajes de programación los tratan.

¿Qué es la seguridad de la memoria?

La seguridad de la memoria se refiere al estado de una aplicación de software donde los punteros de memoria o las referencias siempre se refieren a la memoria válida. Debido a que la corrupción de la memoria es una posibilidad, hay muy pocas garantías sobre el comportamiento de un programa si no es seguro para la memoria. En pocas palabras, si un programa no es realmente seguro para la memoria, hay pocas garantías sobre su funcionalidad. Cuando se trata de un programa que no es seguro para la memoria, una parte malintencionada puede usar la falla para leer secretos o ejecutar código arbitrario en la máquina de otra persona.


Diseñado por Freepik.


Usemos un pseudocódigo para ver qué memoria válida es.


 // pseudocode #1 - shows valid reference { // scope starts here int x = 5 int y = &x } // scope ends here


En el pseudocódigo anterior, hemos creado una variable x asignada con un valor de 10 . Usamos el operador & o la palabra clave para crear una referencia. Por lo tanto, la sintaxis de &x nos permite crear una referencia que se refiere al valor de x . En pocas palabras, hemos creado una variable x que posee 5 y una variable y que es una referencia a x .


Dado que ambas variables x e y están en el mismo bloque o ámbito, la variable y tiene una referencia válida que se refiere al valor de x . Como resultado, la variable y tiene un valor de 5 .


Eche un vistazo al siguiente pseudocódigo. Como podemos ver, el alcance de x está limitado al bloque en el que se crea. Nos metemos en referencias colgantes cuando intentamos acceder a x fuera de su alcance. ¿Referencia pendiente…? ¿Qué es exactamente?


 // pseudocode #2 - shows invalid reference aka dangling reference { // scope starts here int x = 5 } // scope ends here int y = &x // can't access x from here; creates dangling reference


Referencia colgante

Una referencia colgante es un puntero que apunta a una ubicación de memoria que se le ha dado a otra persona o se ha liberado (liberado). Si un programa (también conocido como proceso ) se refiere a la memoria que se ha liberado o borrado, podría fallar o causar resultados no deterministas.


Habiendo dicho eso, la inseguridad de la memoria es una propiedad de algunos lenguajes de programación que permite a los programadores manejar datos no válidos. Como resultado, la falta de seguridad de la memoria introdujo una variedad de problemas que podrían causar las siguientes vulnerabilidades de seguridad importantes:


  • Lecturas fuera de los límites
  • Escrituras fuera de los límites
  • Use-After-Free


Las vulnerabilidades causadas por la falta de seguridad de la memoria son la raíz de muchas otras amenazas de seguridad graves. Desafortunadamente, descubrir estas vulnerabilidades puede ser un gran desafío para los desarrolladores.

¿Qué es una pérdida de memoria?

Es importante comprender qué es una fuga de memoria y cuáles son sus consecuencias.


Diseñado por Freepik.


Una fuga de memoria es una forma involuntaria de consumo de memoria en la que el desarrollador no puede liberar un bloque asignado de memoria en montón cuando ya no se necesita. Es simplemente lo opuesto a la seguridad de la memoria. Más sobre los diferentes tipos de memoria más adelante, pero por ahora, solo sepa que una pila almacena variables de longitud fija conocidas en el momento de la compilación, mientras que el tamaño de las variables que pueden cambiar más tarde en el tiempo de ejecución debe colocarse en el montón .


En comparación con la asignación de memoria de montón, la asignación de memoria de pila se considera más segura, ya que la memoria se libera automáticamente cuando ya no es relevante o necesaria, ya sea por el programador o por el propio tiempo de ejecución del programa.


Sin embargo, cuando los programadores generan memoria en el montón y no la eliminan en ausencia de un recolector de basura (en el caso de C y C++), se desarrolla una fuga de memoria. Además, si perdemos todas las referencias a un fragmento de memoria sin desasignar esa memoria, entonces tenemos una fuga de memoria. Nuestro programa seguirá siendo dueño de esa memoria, pero no tiene forma de volver a usarla.


Una pequeña fuga de memoria no es un problema, pero si un programa asigna una mayor cantidad de memoria y nunca la desasigna, la huella de memoria del programa seguirá aumentando, lo que provocará una denegación de servicio.


Cuando un programa sale, el sistema operativo recupera inmediatamente toda la memoria que posee. Como resultado, una fuga de memoria solo afecta a un programa mientras se está ejecutando; no tiene efecto una vez que el programa ha terminado.


Repasemos las consecuencias clave de las fugas de memoria.


Las fugas de memoria reducen el rendimiento de la computadora al reducir la cantidad de memoria disponible (memoria en montón). Eventualmente hace que la totalidad o una parte del sistema deje de funcionar correctamente o se ralentice severamente. Los bloqueos suelen estar relacionados con pérdidas de memoria.


Nuestro enfoque para descubrir cómo prevenir las fugas de memoria variará según el lenguaje de programación que estemos usando. Las fugas de memoria pueden comenzar como un problema pequeño y casi "imperceptible", pero pueden escalar muy rápidamente y abrumar los sistemas a los que afectan. Siempre que sea factible, debemos estar atentos a ellos y tomar medidas para rectificarlos en lugar de dejar que crezcan.

Inseguridad de la memoria frente a fugas de memoria

Las fugas de memoria y la inseguridad de la memoria son los dos tipos de problemas que han recibido la mayor atención en términos de prevención y reparación. Es importante tener en cuenta que arreglar uno no arregla automáticamente el otro.


Figura 1: inseguridad de la memoria frente a fugas de memoria.


Varios tipos de memorias y cómo funcionan

Antes de continuar, es importante comprender los diferentes tipos de memoria que usará nuestro código en tiempo de ejecución.


Hay dos tipos de memoria, como sigue, y estas memorias están estructuradas de manera diferente.

  • Registro del procesador

  • Estático

  • Pila

  • Montón


Tanto el registro del procesador como los tipos de memoria estática están fuera del alcance de esta publicación.

Memoria de pila y cómo funciona

La pila almacena datos en el orden en que se reciben y los elimina en el orden inverso. Se puede acceder a los elementos desde la pila en el orden de último en entrar, primero en salir (LIFO). Agregar datos a la pila se denomina "empujar" y eliminar datos de la pila se denomina "hacer estallar".


Todos los datos almacenados en la pila deben tener un tamaño fijo conocido. En su lugar, los datos con un tamaño desconocido en el momento de la compilación o un tamaño que podría cambiar más adelante deben almacenarse en el montón.


Como desarrolladores, no tenemos que preocuparnos por la asignación y desasignación de memoria de pila; el compilador "realiza automáticamente" la asignación y desasignación de memoria de pila. Implica que cuando los datos en la pila ya no son relevantes (fuera del alcance), se eliminan automáticamente sin necesidad de nuestra intervención.


Este tipo de asignación de memoria también se conoce como asignación de memoria temporal , porque tan pronto como la función finaliza su ejecución, todos los datos que pertenecen a esa función se eliminan de la pila "automáticamente".


Todos los tipos primitivos en Rust viven en la pila. Tipos como números, caracteres, segmentos, booleanos, matrices de tamaño fijo, tuplas que contienen primitivas y punteros de función pueden sentarse en la pila.


Memoria de montón y cómo funciona

A diferencia de una pila, cuando colocamos datos en el montón, solicitamos una cierta cantidad de espacio. El asignador de memoria localiza un lugar desocupado lo suficientemente grande en el montón, lo marca como en uso y devuelve una referencia a la dirección de esa ubicación. Esto se conoce como asignación .


Asignar en el montón es más lento que empujar a la pila porque el asignador nunca tiene que buscar una ubicación vacía para colocar nuevos datos. Además, debido a que debemos seguir un puntero para llegar a los datos del montón, es más lento que acceder a los datos de la pila. A diferencia de la pila, que se asigna y desasigna en el momento de la compilación, la memoria de almacenamiento dinámico se asigna y desasigna durante la ejecución de las instrucciones de un programa.


En algunos lenguajes de programación, para asignar memoria de montón, usamos la palabra clave new . Esta new palabra clave (también conocida como operador ) denota una solicitud de asignación de memoria en el montón. Si hay suficiente memoria disponible en el montón, el operador new inicializa la memoria y devuelve la dirección única de esa memoria recién asignada.


Vale la pena mencionar que el programador o el tiempo de ejecución desasigna "explícitamente" la memoria del montón.

¿Cómo varios otros lenguajes de programación garantizan la seguridad de la memoria?

Cuando se trata de la gestión de la memoria, particularmente la memoria en montón, preferimos que nuestros lenguajes de programación tengan las siguientes características:

  • Preferimos liberar la memoria lo antes posible cuando ya no se necesite, sin sobrecarga de tiempo de ejecución.
  • Nunca debemos mantener una referencia a un dato que ha sido liberado (también conocido como una referencia pendiente). De lo contrario, podrían producirse bloqueos y problemas de seguridad.


La seguridad de la memoria se garantiza de diferentes maneras mediante lenguajes de programación mediante:

  • Desasignación de memoria explícita (adoptada por C, C++)
  • Desasignación automática o implícita de memoria (adoptada por Java, Python y C#)
  • Gestión de memoria basada en regiones
  • Sistemas de tipos lineales o únicos


Tanto la administración de memoria basada en regiones como los sistemas de tipo lineal están más allá del alcance de esta publicación.

Desasignación de memoria manual o explícita

Los programadores deben liberar o borrar "manualmente" la memoria asignada cuando usan la administración de memoria explícita. Un operador de "desasignación" (por ejemplo, delete en C) existe en lenguajes con desasignación de memoria explícita.


La recolección de basura es demasiado costosa en lenguajes de sistemas como C y C ++, por lo tanto, la asignación de memoria explícita continúa existiendo.


Dejar la responsabilidad de liberar memoria al programador tiene el beneficio de darle al programador un control total sobre el ciclo de vida de la variable. Sin embargo, si los operadores de desasignación se usan incorrectamente, puede ocurrir una falla de software durante la ejecución. De hecho, este proceso manual de asignación y liberación es propenso a errores. Algunos errores de codificación comunes incluyen:

  • Referencia colgante
  • Pérdida de memoria


A pesar de esto, preferimos la gestión manual de la memoria a la recolección de elementos no utilizados, ya que nos da más control y proporciona un mejor rendimiento. Tenga en cuenta que el objetivo de cualquier lenguaje de programación de sistemas es llegar lo más "cerca del metal" como sea posible. En otras palabras, favorecen un mejor rendimiento sobre las características de conveniencia en la compensación.


Es enteramente nuestra responsabilidad (desarrolladores) asegurarnos de que nunca se utilice ningún puntero al valor que liberamos.


En el pasado reciente, ha habido varios patrones comprobados para evitar estos errores, pero todo se reduce a mantener una disciplina de código rigurosa, lo que requiere aplicar el método de administración de memoria correcto de manera consistente.


Los puntos clave son:

  • Tener un mayor control sobre la gestión de la memoria.
  • Menos seguridad como resultado de referencias colgantes y pérdidas de memoria.
  • Resultados en un tiempo de desarrollo más largo.

Desasignación de memoria automática o implícita

La gestión automática de la memoria se ha convertido en una característica esencial de todos los lenguajes de programación modernos, incluido Java.


En el caso de la desasignación automática de memoria, los recolectores de basura sirven como administradores de memoria automáticos. Estos recolectores de basura revisan periódicamente el montón y reciclan fragmentos de memoria que no se utilizan. Gestionan la asignación y liberación de memoria en nuestro nombre. Así que no tenemos que escribir código para realizar tareas de administración de memoria. Eso es genial ya que los recolectores de basura nos liberan de la responsabilidad de administrar la memoria. Otra ventaja es que reduce el tiempo de desarrollo.


La recolección de basura, por otro lado, tiene una serie de inconvenientes. Durante la recolección de basura, el programa debe hacer una pausa y dedicar tiempo a determinar qué necesita limpiar antes de continuar.


Además, la gestión automática de la memoria tiene mayores necesidades de memoria. Esto se debe al hecho de que un recolector de elementos no utilizados realiza la desasignación de memoria por nosotros, lo que consume tanto memoria como ciclos de CPU. Como resultado, la gestión automatizada de la memoria puede degradar el rendimiento de las aplicaciones, especialmente en aplicaciones grandes con recursos limitados.


Los puntos clave son:

  • Elimina la necesidad de que los desarrolladores liberen la memoria manualmente.
  • Proporciona seguridad de memoria eficiente sin referencias colgantes ni fugas de memoria.
  • Código más simple y directo.
  • Ciclo de desarrollo más rápido.
  • Tener menos control sobre la gestión de la memoria.
  • Provoca latencia ya que consume memoria y ciclos de CPU.

¿Cómo garantiza Rust la seguridad de la memoria?

Algunos lenguajes proporcionan recolección de basura , que busca memoria que ya no está en uso mientras se ejecuta el programa; otros requieren que el programador asigne y libere memoria explícitamente . Ambos modelos tienen ventajas y desventajas. La recolección de basura, aunque quizás sea la más utilizada, tiene algunos inconvenientes; facilita la vida de los desarrolladores a expensas de los recursos y el rendimiento.


Habiendo dicho eso, uno brinda un control de administración de memoria eficiente, mientras que el otro brinda mayor seguridad al eliminar las referencias colgantes y las fugas de memoria. Rust combina los beneficios de ambos mundos.


Figura 2: Rust tiene un mejor control sobre la gestión de la memoria y proporciona una mayor seguridad sin problemas de memoria.


Rust adopta un enfoque diferente al de los otros dos, basado en un modelo de propiedad con un conjunto de reglas que el compilador verifica para garantizar la seguridad de la memoria. El programa no compilará si se viola alguna de estas reglas. De hecho, la propiedad reemplaza la recolección de basura en tiempo de ejecución con verificaciones en tiempo de compilación para la seguridad de la memoria.


Gestión de memoria explícita frente a Gestión de memoria implícita frente al modelo de propiedad de Rust.


Lleva algún tiempo acostumbrarse a la propiedad porque es un concepto nuevo para muchos programadores, como yo.

Propiedad

En este punto, tenemos una comprensión básica de cómo se almacenan los datos en la memoria. Veamos más de cerca la propiedad en Rust. La principal característica distintiva de Rust es la propiedad, que garantiza la seguridad de la memoria en tiempo de compilación.


Para empezar, definamos “propiedad” en su sentido más literal. La propiedad es el estado de “poseer” y “controlar” la posesión legal de “algo”. Dicho esto, debemos identificar quién es el propietario y qué posee y controla el propietario . En Rust, cada valor tiene una variable llamada propietario . En pocas palabras, una variable es un propietario, y el valor de una variable es lo que posee y controla el propietario.


Figura 3: El enlace de variables muestra el propietario y su valor/recurso.


Con un modelo de propiedad, la memoria se libera automáticamente una vez que la variable que la posee queda fuera del alcance. Cuando los valores quedan fuera del alcance o su duración finaliza por algún otro motivo, se llama a sus destructores. Un destructor, en particular un destructor automatizado, es una función que elimina los rastros de un valor del programa mediante la eliminación de referencias y libera memoria.

Comprobador de préstamo

Rust implementa la propiedad a través del verificador de préstamo , un analizador estático . El verificador de préstamos es un componente del compilador de Rust que realiza un seguimiento de dónde se usan los datos a lo largo del programa y, al seguir las reglas de propiedad, puede determinar dónde se deben liberar los datos. Además, el comprobador de préstamo garantiza que nunca se pueda acceder a la memoria desasignada en tiempo de ejecución. Incluso elimina la posibilidad de carreras de datos causadas por mutaciones (modificaciones) concurrentes.

Reglas de propiedad

Como se indicó anteriormente, el modelo de propiedad se basa en un conjunto de reglas conocidas como reglas de propiedad , y estas reglas son relativamente sencillas. El compilador de Rust (rustc) aplica estas reglas:

  • En Rust, cada valor tiene una variable llamada propietario.
  • Solo puede haber un propietario a la vez.
  • Cuando el propietario queda fuera del alcance, el valor se eliminará.


Los siguientes errores de memoria están protegidos por estas reglas de propiedad de verificación en tiempo de compilación:

  • Referencias colgantes: aquí es donde una referencia apunta a una dirección de memoria que ya no contiene los datos a los que se refería el puntero; este puntero apunta a datos nulos o aleatorios.
  • Usar después de liberar: aquí es donde se accede a la memoria una vez que se ha liberado, lo que puede bloquearse. Esta ubicación de memoria también puede ser utilizada por piratas informáticos para ejecutar código.
  • Liberaciones dobles: aquí es donde se libera la memoria asignada y luego se libera nuevamente. Esto podría causar que el programa se bloquee, lo que podría exponer información confidencial. Esto también permite que un pirata informático ejecute cualquier código que elija.
  • Fallas de segmentación: aquí es donde el programa intenta acceder a la memoria a la que no tiene permitido acceder.
  • Desbordamiento de búfer: aquí es donde el volumen de datos excede la capacidad de almacenamiento del búfer de memoria, lo que hace que el programa se bloquee.


Antes de entrar en los detalles de cada regla de propiedad, es importante comprender las distinciones entre copiar , mover y clonar .

Copiar

Un tipo con un tamaño fijo (particularmente los tipos primitivos) puede almacenarse en la pila y extraerse cuando finaliza su alcance, y puede copiarse rápida y fácilmente para crear una nueva variable independiente si otra parte del código requiere el mismo valor en un alcance diferente. Debido a que copiar la memoria de la pila es económico y rápido, se dice que los tipos primitivos con un tamaño fijo tienen semántica de copia . Crea económicamente una réplica perfecta (un duplicado).


Vale la pena señalar que los tipos primitivos con tamaño fijo implementan el rasgo de copia para hacer copias.


 let x = "hello"; let y = x; println!("{}", x) // hello println!("{}", y) // hello


En Rust, hay dos tipos de cadenas: String (asignada en montón y ampliable) y &str (tamaño fijo y no se puede mutar).


Debido a que x se almacena en la pila, es más fácil copiar su valor para producir otra copia para y . Este no es el caso de un valor que se almacena en el montón. Así es como se ve el marco de la pila:


Figura 4: Tanto x como y tienen sus propios datos.

La duplicación de datos aumenta el tiempo de ejecución del programa y el consumo de memoria. Por lo tanto, copiar no es una buena opción para grandes cantidades de datos.

Muevete

En la terminología de Rust, "mover" significa que la propiedad de la memoria se transfiere a otro propietario. Considere el caso de los tipos complejos que se almacenan en el montón.


 let s1 = String::from("hello"); let s2 = s1;


Podríamos suponer que la segunda línea (es decir let s2 = s1; ) haría una copia del valor en s1 y lo vincularía a s2 . Pero este no es el caso.


Eche un vistazo al siguiente para ver qué le sucede a String debajo del capó. Una cadena se compone de tres partes, que se almacenan en la pila . Los contenidos reales (hola, en este caso) se almacenan en el montón .

  • Puntero : apunta a la memoria que contiene el contenido de la cadena.
  • Longitud : es la cantidad de memoria, en bytes, que utiliza actualmente el contenido de String .
  • Capacidad : es la cantidad total de memoria, en bytes, que String ha recibido del asignador.


En otras palabras, los metadatos se mantienen en la pila mientras que los datos reales se mantienen en el montón.


Figura 5: La pila contiene los metadatos mientras que el montón contiene el contenido real.


Cuando asignamos s1 a s2 , los metadatos de la String se copian, lo que significa que copiamos el puntero, la longitud y la capacidad que están en la pila. No copiamos los datos en el montón al que hace referencia el puntero. La representación de datos en la memoria se parece a la siguiente:


Figura 6: la variable s2 obtiene una copia del puntero, la longitud y la capacidad de s1.


Vale la pena señalar que la representación no se parece a la siguiente, que es como se vería la memoria si Rust también copiara los datos del montón. Si Rust hiciera esto, la operación s2 = s1 podría ser extremadamente lenta en términos de rendimiento en tiempo de ejecución si los datos del montón fueran grandes.


Figura 7: si Rust copió los datos del montón, otra posibilidad de lo que podría hacer let s2 = s1 es la replicación de datos. Sin embargo, Rust no copia por defecto.


Tenga en cuenta que cuando los tipos complejos ya no están dentro del alcance, Rust drop a la función de eliminación para desasignar explícitamente la memoria del montón. Sin embargo, ambos punteros de datos en la Figura 6 apuntan a la misma ubicación, que no es la forma en que funciona Rust. Entraremos en detalles en breve.


Como se indicó anteriormente, cuando asignamos s1 a s2 , la variable s2 recibe una copia de los metadatos de s1 (puntero, longitud y capacidad). Pero, ¿qué sucede con s1 una vez que se ha asignado a s2 ? Rust ya no considera que s1 sea válido. Sí, has leído bien.


Pensemos en esta asignación let s2 = s1 por un momento. Considere lo que sucede si Rust todavía considera que s1 es válido después de esta asignación. Cuando s2 y s1 quedan fuera del alcance, ambos intentarán liberar la misma memoria. Uh-oh, eso no es bueno. Esto se conoce como un error doble libre y es uno de los errores de seguridad de la memoria. La corrupción de la memoria puede ser el resultado de liberar la memoria dos veces, lo que representa un riesgo para la seguridad.


Para garantizar la seguridad de la memoria, Rust consideró que s1 no era válido después de la línea let s2 = s1 . Por lo tanto, cuando s1 ya no está dentro del alcance, Rust no necesita publicar nada. Examine qué sucede si tratamos de usar s1 después de que se haya creado s2 .


 let s1 = String::from("hello"); let s2 = s1; println!("{}, world!", s1); // Won't compile. We'll get an error.


Obtendremos un error como el siguiente porque Rust le impide usar la referencia invalidada:


 $ cargo run Compiling playground v0.0.1 (/playground) error[E0382]: borrow of moved value: `s1` --> src/main.rs:6:28 | 3 | let s1 = String::from("hello"); | -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait 4 | let s2 = s1; | -- value moved here 5 | 6 | println!("{}, world!", s1); | ^^ value borrowed here after move | = note: this error originates in the macro `$crate::format_args_nl` (in Nightly builds, run with -Z macro-backtrace for more info) For more information about this error, try `rustc --explain E0382`.


Como Rust "trasladó" la propiedad de la memoria de s1 a s2 después de la línea let s2 = s1 , consideró que s1 no era válido. Aquí está la representación de la memoria después de que s1 haya sido invalidado:


Figura 8: Representación de la memoria después de que s1 haya sido invalidado.


Cuando solo s2 sigue siendo válido, solo liberará la memoria cuando salga del alcance. Como resultado, en Rust se elimina la posibilidad de un doble error gratuito . ¡Eso es maravilloso!

clon

Si queremos copiar profundamente los datos del montón de String , no solo los datos de la pila, podemos usar un método llamado clone . Aquí hay un ejemplo de cómo usar el método de clonación:


 let s1 = String::from("hello"); let s2 = s1.clone(); println!("s1 = {}, s2 = {}", s1, s2);


Cuando se usa el método de clonación, los datos del montón se copian en s2. Esto funciona perfectamente y produce el siguiente comportamiento:


Figura 9: cuando se usa el método de clonación, los datos del montón se copian en s2.


El uso del método de clonación tiene serias consecuencias; no solo copia los datos, sino que tampoco sincroniza ningún cambio entre los dos. En general, los clones deben planificarse con cuidado y con plena conciencia de las consecuencias.


A estas alturas, deberíamos poder distinguir entre copiar, mover y clonar. Veamos ahora cada regla de propiedad con más detalle.

Regla de propiedad 1

Cada valor tiene una variable llamada su propietario. Implica que todos los valores son propiedad de las variables. En el siguiente ejemplo, la variable s posee el puntero a nuestra cadena, y en la segunda línea, la variable x posee un valor 1.


 let s = String::from("Rule 1"); let n = 1;

Regla de propiedad 2

Solo puede haber un propietario de un valor en un momento dado. Uno puede tener muchas mascotas, pero cuando se trata del modelo de propiedad, solo hay un valor en un momento dado :-)


Diseñado por Freepik.


Veamos el ejemplo usando primitivas , que son de tamaño fijo conocido en tiempo de compilación.


 let x = 10; let y = x; let z = x;


Hemos tomado 10 y lo hemos asignado a x ; en otras palabras, x posee 10. Entonces tomamos x y lo asignamos a y y también lo asignamos a z . Sabemos que solo puede haber un propietario en un momento dado, pero aquí no recibimos ningún error. Entonces, lo que sucede aquí es que el compilador está haciendo copias de x cada vez que lo asignamos a una nueva variable.


El marco de pila para esto sería el siguiente: x = 10 , y = 10 y z = 10 . Este, sin embargo, no parece ser el caso como este: x = 10 , y = x , y z = x . Como sabemos, x es el único propietario de este valor 10, y ni y ni z pueden poseer este valor.


Figura 10: el compilador hizo copias de x tanto en y como en z.


Debido a que copiar la memoria de la pila es barato y rápido, se dice que los tipos primitivos con un tamaño fijo tienen semántica de copia , mientras que los tipos complejos mueven la propiedad, como se indicó anteriormente. Así, en este caso, el compilador hace las copias .


En este punto, el comportamiento de vinculación de variables es similar a la de otros lenguajes de programación. Para ilustrar las reglas de propiedad, necesitamos un tipo de datos complejo.


Veamos los datos almacenados en el montón y veamos cómo Rust entiende cuándo limpiarlos; el tipo String es un excelente ejemplo para este caso de uso. Nos centraremos en el comportamiento relacionado con la propiedad de String; estos principios, sin embargo, también se aplican a otros tipos de datos complejos.


El tipo complejo, como sabemos, administra datos en el montón y su contenido se desconoce en el momento de la compilación. Veamos el mismo ejemplo que hemos visto antes:


 let s1 = String::from("hello"); let s2 = s1; println!("{}, world!", s1); // Won't compile. We'll get an error.



En el caso del tipo String , el tamaño podría expandirse y almacenarse en el montón. Esto significa:

  • En tiempo de ejecución, la memoria debe solicitarse al asignador de memoria (llamémoslo primera parte).
  • Cuando terminemos de usar nuestro String , debemos devolver (liberar) esta memoria al asignador (llamémoslo segunda parte).


Nosotros (los desarrolladores) nos encargamos de la primera parte: cuando llamamos a String::from , su implementación solicita la memoria que necesita. Esta parte es casi común en todos los lenguajes de programación.


Sin embargo, la segunda parte es diferente. En lenguajes con un recolector de basura (GC), el GC realiza un seguimiento y limpia la memoria que ya no está en uso, y no tenemos que preocuparnos por eso. En idiomas sin un recolector de basura, es nuestra responsabilidad identificar cuándo ya no se necesita memoria y pedir que se libere explícitamente. Siempre ha sido una tarea de programación desafiante hacer esto correctamente:

  • Desperdiciaremos la memoria si olvidamos.
  • Tendremos una variable inválida si lo hacemos demasiado pronto.
  • Obtendremos un error si lo hacemos dos veces.


Rust maneja la desasignación de memoria de una manera novedosa para hacernos la vida más fácil: la memoria se devuelve automáticamente una vez que la variable que la posee queda fuera del alcance.


Volvamos a los negocios. En Rust, para tipos complejos, operaciones como asignar un valor a una variable, pasarlo a una función o devolverlo desde una función no copian el valor: lo mueven. En pocas palabras, los tipos complejos mueven la propiedad.


Cuando los tipos complejos ya no están dentro del alcance, Rust llamará a la función de eliminación para desasignar explícitamente la memoria del montón.


Regla de propiedad 3

Cuando el propietario queda fuera del alcance, el valor se eliminará. Consideremos de nuevo el caso anterior:


 let s1 = String::from("hello"); let s2 = s1; println!("{}, world!", s1); // Won't compile. The value of s1 has already been dropped.


El valor de s1 ha disminuido después de que s1 se asigna a s2 (en la instrucción de asignación let s2 = s1 ). Por lo tanto, s1 ya no es válido después de esta asignación. Aquí está la representación de la memoria después de que se haya descartado s1:


Figura 11: Representación de la memoria después de descartar s1.

Cómo se mueve la propiedad

Hay tres formas de transferir la propiedad de una variable a otra en un programa de Rust:

  1. Asignar el valor de una variable a otra variable (ya se discutió).
  2. Pasar valor a una función.
  3. Volviendo de una función.

Pasar valor a una función

Pasar un valor a una función tiene una semántica similar a asignar un valor a una variable. Al igual que la asignación, pasar una variable a una función hace que se mueva o se copie. Eche un vistazo a este ejemplo, que muestra los casos de uso de copiar y mover:


 fn main() { let s = String::from("hello"); // s comes into scope move_ownership(s); // s's value moves into the function... // so it's no longer valid from this // point forward let x = 5; // x comes into scope makes_copy(x); // x would move into the function // It follows copy semantics since it's // primitive, so we use x afterward } // Here, x goes out of scope, then s. But because s's value was moved, nothing // special happens. fn move_ownership(some_string: String) { // some_string comes into scope println!("{}", some_string); } // Here, some_string goes out of scope and `drop` is called. // The occupied memory is freed. fn makes_copy(some_integer: i32) { // some_integer comes into scope println!("{}", some_integer); } // Here, some_integer goes out of scope. Nothing special happens.


Si intentáramos usar s después de la llamada a move_ownership , Rust arrojaría un error de tiempo de compilación.

Volviendo de una función

Los valores devueltos también pueden transferir la propiedad. El siguiente ejemplo muestra una función que devuelve un valor, con anotaciones idénticas a las del ejemplo anterior.


 fn main() { let s1 = gives_ownership(); // gives_ownership moves its return // value into s1 let s2 = String::from("hello"); // s2 comes into scope let s3 = takes_and_gives_back(s2); // s2 is moved into // takes_and_gives_back, which also // moves its return value into s3 } // Here, s3 goes out of scope and is dropped. s2 was moved, so nothing // happens. s1 goes out of scope and is dropped. fn gives_ownership() -> String { // gives_ownership will move its // return value into the function // that calls it let some_string = String::from("yours"); // some_string comes into scope some_string // some_string is returned and // moves out to the calling // function } // This function takes a String and returns it fn takes_and_gives_back(a_string: String) -> String { // a_string comes into // scope a_string // a_string is returned and moves out to the calling function }


La propiedad de una variable siempre sigue el mismo patrón: un valor se mueve cuando se asigna a otra variable . A menos que la propiedad de los datos se haya trasladado a otra variable, cuando una variable que incluye datos en el montón queda fuera del alcance, el valor se eliminará mediante drop .


Con suerte, esto nos brinda una comprensión básica de qué es un modelo de propiedad y cómo influye en la forma en que Rust maneja los valores, como asignarlos entre sí y pasarlos dentro y fuera de las funciones.


Esperar. Una cosa más…


El modelo de propiedad de Rust, como todas las cosas buenas, tiene ciertos inconvenientes. Rápidamente nos damos cuenta de ciertos inconvenientes una vez que comenzamos a trabajar en Rust. Es posible que hayamos observado que tomar posesión y luego devolver la propiedad con cada función es un poco inconveniente.

Diseñado por Freepik.


Es molesto que todo lo que pasamos a una función deba ser devuelto si queremos volver a utilizarla, además de cualquier otro dato devuelto por esa función. ¿Qué pasa si queremos que una función use un valor sin tomar posesión de él?


Considere el siguiente ejemplo. El siguiente código generará un error porque la variable v ya no puede ser utilizada por la función main (¡en println! ) que la poseía inicialmente una vez que la propiedad se transfiere a la función print_vector .


 fn main() { let v = vec![10,20,30]; print_vector(v); println!("{}", v[0]); // this line gives us an error } fn print_vector(x: Vec<i32>) { println!("Inside print_vector function {:?}",x); }


El seguimiento de la propiedad puede parecer bastante fácil, pero puede complicarse cuando empezamos a tratar con programas grandes y complejos. Así que necesitamos una forma de transferir valores sin transferir la propiedad, que es donde entra en juego el concepto de préstamo .

Préstamo

Tomar prestado, en su sentido literal, se refiere a recibir algo con la promesa de devolverlo. En el contexto de Rust, pedir prestado es una forma de acceder al valor sin reclamar su propiedad, ya que debe devolverse a su propietario en algún momento.


Diseñado por Freepik.


Cuando tomamos prestado un valor, hacemos referencia a su dirección de memoria con el operador & . A & se llama una referencia . Las referencias en sí mismas no son nada especial; bajo el capó, son solo direcciones. Para aquellos familiarizados con los punteros C, una referencia es un puntero a la memoria que contiene un valor que pertenece a (también conocido como propiedad de) otra variable. Vale la pena señalar que una referencia no puede ser nula en Rust. De hecho, una referencia es un puntero ; es el tipo más básico de puntero. Solo hay un tipo de puntero en la mayoría de los idiomas, pero Rust tiene diferentes tipos de punteros, en lugar de uno solo. Los punteros y sus diversos tipos son un tema diferente que se discutirá por separado.


En pocas palabras, Rust se refiere a la creación de una referencia a algún valor como un préstamo del valor, que eventualmente debe devolverse a su propietario.


Veamos un ejemplo simple a continuación:


 let x = 5; let y = &x; println!("Value y={}", y); println!("Address of y={:p}", y); println!("Deref of y={}", *y);


Lo anterior produce el siguiente resultado:


 Value y=5 Address of y=0x7fff6c0f131c Deref of y=5


Aquí, la variable y toma prestado el número que pertenece a la variable x , mientras que x aún posee el valor. Llamamos y una referencia a x . El préstamo finaliza cuando y sale del alcance y, como y no posee el valor, no se destruye. Para tomar prestado un valor, tome una referencia del operador & . El formato p, salida {:p} como una ubicación de memoria presentada como hexadecimal.


En el código anterior, "*" (es decir, un asterisco) es un operador de desreferencia que opera en una variable de referencia. Este operador de desreferenciación nos permite obtener el valor almacenado en la dirección de memoria de un puntero.


Veamos cómo una función puede usar un valor sin tomar posesión a través de préstamos:


 fn main() { let v = vec![10,20,30]; print_vector(&v); println!("{}", v[0]); // can access v here as references can't move the value } fn print_vector(x: &Vec<i32>) { println!("Inside print_vector function {:?}", x); }


Estamos pasando una referencia ( &v ) (también conocida como pass-by-reference ) a la función print_vector en lugar de transferir la propiedad (es decir, pass-by-value ). Como resultado, después de llamar a la función print_vector en la función principal, podemos acceder a v .

Seguimiento del puntero al valor con el operador de desreferencia

Como se indicó anteriormente, una referencia es una especie de puntero, y un puntero puede considerarse como una flecha que apunta a un valor almacenado en otro lugar. Considere el siguiente ejemplo:


 let x = 5; let y = &x; assert_eq!(5, x); assert_eq!(5, *y);


En el código anterior, creamos una referencia a un valor de tipo i32 y luego usamos el operador de desreferencia para seguir la referencia a los datos. La variable x tiene un valor de tipo i32 , 5 . Igualamos y a una referencia a x .


Así es como aparece la memoria de la pila:


Representación de la memoria de pila.


Podemos afirmar que x es igual a 5 . Sin embargo, si queremos hacer una afirmación sobre el valor en y , debemos seguir la referencia al valor al que se refiere usando *y (por lo tanto, desreferencia aquí). Una vez que eliminamos la referencia a y , tenemos acceso al valor entero al que apunta y , que podemos comparar con 5 .


Si tratamos de escribir assert_eq!(5, y); en su lugar, obtendríamos este error de compilación:


 error[E0277]: can't compare `{integer}` with `&{integer}` --> src/main.rs:11:5 | 11 | assert_eq!(5, y); | ^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}`


Debido a que son tipos diferentes, no se permite comparar un número y una referencia a un número. Por lo tanto, debemos usar el operador de desreferencia para seguir la referencia al valor al que apunta.

Las referencias son inmutables por defecto

Al igual que una variable, una referencia es inmutable de forma predeterminada; se puede convertir en mutable con mut , pero solo si su propietario también es mutable:


 let mut x = 5; let y = &mut x;


Las referencias inmutables también se conocen como referencias compartidas, mientras que las referencias mutables también se conocen como referencias exclusivas.


Considere el siguiente caso. Estamos otorgando acceso de solo lectura a las referencias ya que estamos usando el operador & en lugar de &mut . Incluso si la fuente n es mutable, ref_to_n y another_ref_to_n no lo son, ya que son de solo lectura.


 let mut n = 10; let ref_to_n = &n; let another_ref_to_n = &n;


Borrow checker dará el siguiente error:


 error[E0596]: cannot borrow `x` as mutable, as it is not declared as mutable --> src/main.rs:4:9 | 3 | let x = 5; | - help: consider changing this to be mutable: `mut x` 4 | let y = &mut x; | ^^^^^^ cannot borrow as mutable


Reglas de préstamo

Uno podría preguntarse por qué un préstamo no siempre sería preferible a una mudanza . Si ese es el caso, ¿por qué Rust incluso tiene una semántica de movimiento y por qué no la toma prestada de forma predeterminada? La razón es que no siempre es posible tomar prestado un valor en Rust. El endeudamiento sólo está permitido en determinados casos.


El préstamo tiene su propio conjunto de reglas, que el verificador de préstamo aplica estrictamente durante el tiempo de compilación. Estas reglas se establecieron para evitar carreras de datos . Son los siguientes:

  1. El alcance del prestatario no puede durar más que el alcance del propietario original.
  2. Puede haber varias referencias inmutables, pero solo una referencia mutable.
  3. Los propietarios pueden tener referencias inmutables o mutables, pero no ambas al mismo tiempo.
  4. Todas las referencias deben ser válidas (no pueden ser nulas).

La referencia no debe sobrevivir al propietario.

El alcance de una referencia debe estar contenido dentro del alcance del propietario del valor. De lo contrario, la referencia puede hacer referencia a un valor liberado, lo que genera un error de uso después de la liberación.


 let x; { let y = 0; x = &y; } println!("{}", x);


El programa anterior intenta desreferenciar x después de que el propietario y quede fuera del alcance. Rust previene este error de uso después de liberar .

Muchas referencias inmutables, pero solo se permite una referencia mutable

Podemos tener tantas referencias inmutables (también conocidas como referencias compartidas) a un dato en particular a la vez, pero solo se permite una referencia mutable (también conocida como referencia exclusiva) a la vez. Esta regla existe para eliminar carreras de datos . Cuando dos referencias apuntan a la misma ubicación de memoria al mismo tiempo, al menos una de ellas está escribiendo y sus acciones no están sincronizadas, esto se conoce como carrera de datos.


Podemos tener tantas referencias inmutables como queramos porque no cambian los datos. El préstamo, por otro lado, nos restringe a solo mantener una referencia mutable ( &mut ) a la vez para evitar la posibilidad de carreras de datos en el momento de la compilación.


Veamos este:


 fn main() { let mut s = String::from("hello"); let r1 = &mut s; let r2 = &mut s; println!("{}, {}", r1, r2); }


El código anterior que intenta crear dos referencias mutables ( r1 y r2 ) a s fallará:


 error[E0499]: cannot borrow `s` as mutable more than once at a time --> src/main.rs:6:14 | 5 | let r1 = &mut s; | ------ first mutable borrow occurs here 6 | let r2 = &mut s; | ^^^^^^ second mutable borrow occurs here 7 | 8 | println!("{}, {}", r1, r2); | -- first borrow later used here


Palabras de cierre

Con suerte, esto aclarará los conceptos de propiedad y préstamo. También mencioné brevemente el verificador de préstamos, la columna vertebral de la propiedad y los préstamos. Como mencioné al principio, la propiedad es una idea novedosa que puede ser difícil de comprender al principio, incluso para desarrolladores experimentados, pero se vuelve más y más fácil cuanto más trabajas en ella. Este es solo un resumen de cómo se aplica la seguridad de la memoria en Rust. Traté de hacer que esta publicación fuera lo más fácil de entender posible y al mismo tiempo proporcionar suficiente información para comprender los conceptos. Para obtener más detalles sobre la función de propiedad de Rust, consulte su documentación en línea.


Rust es una excelente opción cuando el rendimiento es importante y resuelve los problemas que molestan a muchos otros idiomas, lo que da como resultado un importante paso adelante con una curva de aprendizaje empinada. Por sexto año consecutivo, Rust ha sido el lenguaje más querido de Stack Overflow , lo que implica que muchas personas que han tenido la oportunidad de usarlo se han enamorado de él. La comunidad de Rust sigue creciendo.


Según los resultados de Rust Survey 2021 : El año 2021 fue sin duda uno de los más trascendentales en la historia de Rust. Vio la fundación de Rust Foundation, la edición 2021 y una comunidad más grande que nunca. Rust parece estar en un buen camino a medida que nos dirigimos hacia el futuro.


¡Feliz aprendizaje!


Diseñado por Freepik.